package server;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import net.Connection;
import net.GameInfo;
import util.Util;

/**
 * The master server for all games.  Its job is to keep track of what clients
 * and tables are connected, and tell the clients about the tables.  A "table"
 * is represented by an instance of <code>GameInfo</code> and refers to a 
 * <code>GameServer</code> that is running a particular game.<br><br>
 * The MasterServer fulfills multiple pattern roles as well.  It is a Mediator (GoF Mediator pattern)
 * and it is the Subject of the GoF Observer pattern as well.
 * The MasterServer uses the following protocol to communicate with LobbyClients
 * (with newlines instead of spaces):<br>
 * <ul>
 * <li><b>tableAdded</b> <i>tableNum address port module numPlayers[ option1 ... optionN] emptyString</i>
 * <li><b>tableRemoved</b> <i>tableNum</i>
 * <li><b>clientAdded</b> <i>userName</i>
 * <li><b>clientRemoved</b> <i>userName</i>
 * <li><b>lobbyMessage</b> message</i>
 * <li><b>privateMessage</b> toUser message</i>
 * </ul>
 */
public class MasterServer
{
	/**
	 * Starts a new master server, listening for new tables on <code>tablePort</code>
	 * and for new clients on <code>clientPort</code>.<br>
	 * Usage: java cards.MasterServer <i>tablePort clientPort</i>
	 */
	public static void main(String[] args)
	{
		Thread tableThread = null, clientThread = null;
		try {
			instance = new MasterServer(Integer.parseInt(args[0]), Integer.parseInt(args[1]));

			// wait for new tables
			(tableThread = new Thread() {
				public void run() {
					try {
						instance.acceptGameServerConnections();
					} catch (IOException e) {e.printStackTrace();}
				}
			}).start();

			// wait for new clients
			(clientThread = new Thread() {
				public void run() {
					try {
						instance.acceptClientConnections();
					} catch (IOException e) {e.printStackTrace();}
				}
			}).start();

			// wait for user input
			java.io.BufferedReader in = new java.io.BufferedReader(new java.io.InputStreamReader(System.in));
			String line;
			while ((line = in.readLine()) != null && !"quit".equals(line))
				;

			// close everything down and exit normally
			try {
				if (tableThread != null) {
					tableThread.interrupt();
					tableThread.join(100);
				}
			} catch (InterruptedException e) {}
			try {
				if (clientThread != null) {
					clientThread.interrupt();
					clientThread.join(100);
				}
			} catch (InterruptedException e) {}
			if (instance != null)
				instance.destruct();
		} catch (ArrayIndexOutOfBoundsException e) {
			Util.quitWithError(USAGE_STRING, 1);
		} catch (NumberFormatException e) {
			Util.quitWithError(USAGE_STRING, 1);
		} catch (Exception e) {
			e.printStackTrace();
			Util.quitWithError("An unexpected exception occurred in MasterServer: " + e.getMessage(), 2);
		}

		System.exit(0);
	}

	private static final String USAGE_STRING = "Usage: java cards.MasterServer tablePort clientPort";

	/**
	 * Object.finalize() is not guaranteed to run at program termination,
	 * so we have to roll our own.
	 */
	protected void destruct()
	{
		try
		{
			if (!tableSocket.isClosed())
				tableSocket.close();
			if (!clientSocket.isClosed())
				clientSocket.close();
			serializeAccounts(accounts.values());
		} catch (IOException e) {e.printStackTrace();}
	}

	/**
	 * Private Singleton constructor
	 */
	private MasterServer(int tablePort, int clientPort) throws IOException {
		System.out.println("Starting server...");

		tableSocket = new java.net.ServerSocket(tablePort);
		clientSocket = new java.net.ServerSocket(clientPort);

		for (Account account : deserializeAccounts())
			accounts.put(account.getUsername(), account);
	}

	/**
	 * Start listening on our port for new LobbyClient connections
	 */
	private void acceptClientConnections() throws IOException
	{
		System.out.println("Accepting clients on port " + clientSocket.getLocalPort());
		for (;;) {
			Connection connection = new Connection(clientSocket.accept());
			try {
				Account account = authenticate(connection.readLine(), connection.readLine());
				if (account != null && !isLoggedOn(account)) {
					addClient(account, connection);
					new ClientListener(account, connection).start();
				}
				else
					connection.socket().close();
			} catch (IOException e) {e.printStackTrace();}
		}
	}

	/**
	 * Wait eternally for new tables to add to our table list.
	 * @throws IOException
	 */
	private void acceptGameServerConnections() throws IOException
	{
		System.out.println("Accepting tables on port " + tableSocket.getLocalPort());
		// wait for new tables until death
		for (;;) {
			Connection connection = new Connection(tableSocket.accept());
			try {
				// get the module name, number of players, and port number of the game server
				java.net.InetAddress host = connection.socket().getInetAddress();
				int newPort = Integer.parseInt(connection.readLine());
				String module = connection.readLine();
				int numPlayers = Integer.parseInt(connection.readLine());
				Map<String, String> options = new HashMap<String, String>();
				String line;
				util.Pair<String, String> option;
				while ((line = connection.readLine()) != null && line.length() > 0)
					options.put((option = Util.splitOnFirstOccurrence(line, '=')).first, option.second);
	
				// start a new thread so that we can get asynchronous notifications
				// of tables closing.
				new TableListener(addTable(new net.GameInfo(host, newPort, module, numPlayers, options)), connection).start();
			}
			catch (IOException e) {e.printStackTrace();}
		}
	}

	private Account authenticate(String username, String password) {
		return (accounts.containsKey(username) && accounts.get(username).getPassword().equals(password)) ?
			accounts.get(username) : null;
	}

	private synchronized boolean isLoggedOn(Account account) {
		return loggedOn.containsKey(account);
	}

	/**
	 * Connects a client to the DedicatedServer if the client supplies the right
	 * credentials. 
	 */
	private synchronized void addClient(Account account, Connection connection) {
		System.out.println("addClient");
		loggedOn.put(account, connection);
		System.out.println(loggedOn.keySet());
		connection.println("welcome");
		for (Integer tableNum : tables.keySet())
			notifyTableAddition(account, tableNum);
		for (Account a : loggedOn.keySet()) {
			if (a != account)
				connection.println("clientAdded" + '\n' + a.getUsername());
			loggedOn.get(a).println("clientAdded" + '\n' + account.getUsername());
		}
	}

	/**
	 * Removes a client from the list of connected Accounts by its Account handle.
	 */
	private synchronized void removeClient(Account account) {
		System.out.println("removeClient");
		loggedOn.remove(account);
		for (Account a : loggedOn.keySet())
			loggedOn.get(a).println("clientRemoved" + '\n' + account.getUsername());
	}

	/**
	 * Adds a new table to the list of available tables.  This table corresponds to a
	 * GameServer, running either locally or on a different machine.
	 * @param info information about the game being played and a link to the machine 
	 * on which it is being played
	 * @return the index of the new table in our list of available tables
	 */
	private synchronized Integer addTable(GameInfo info)
	{
		System.out.println("addTable");
		Integer tableNum = 0;
		while (tables.containsKey(++tableNum))
			;
		tables.put(tableNum, info);

		for (Account account : loggedOn.keySet())
			notifyTableAddition(account, tableNum);
		return tableNum;
	}

	/**
	 * Removes a table from the list of available tables.  This happens when the 
	 * GameServer running the table disconnects from the MasterServer.
	 * @param tableNum the index of the table to remove
	 */
	private synchronized void removeTable(int tableNum) {
		System.out.println("removeTable");
		if (tables.remove(tableNum) != null)
			for (Account account : loggedOn.keySet())
				notifyTableRemoval(account, tableNum);
	}

	private synchronized void sendLobbyMessage(Account from, String message) {
		for (Account a : loggedOn.keySet())
			loggedOn.get(a).println("lobbyMessage" + '\n' + from.getUsername() + '\n' + message);
	}

	private synchronized void sendPrivateMessage(Account from, Account to, String message) {
		loggedOn.get(to).println("privateMessage" + '\n' + from.getUsername() + '\n' + message);
	}

	/**
	 * Notify the given client that a table has been added to the list of available tables.
	 * @param account the client to notify (referenced by the client's Account)
	 * @param tableNum the index of the newly-added table
	 */
	private void notifyTableAddition(Account account, int tableNum)
	{
		GameInfo table = tables.get(tableNum);
		if (table != null)
		{
			Connection conn = loggedOn.get(account);
			conn.println("tableAdded");
			conn.println(tableNum);
			conn.println(table.host.getCanonicalHostName());
			conn.println(table.port);
			conn.println(table.module);
			conn.println(table.numPlayers);
			for (String key : table.options.keySet())
				conn.println(key + "=" + table.options.get(key));
			// an empty line signifies the end of this table's options
			conn.println();
		}
	}

	/**
	 * Notify the given client that a table has been removed from the list of available tables.
	 * @param account the client to notify (referenced by the client's Account)
	 * @param tableNum the index of the removed table
	 */
	private void notifyTableRemoval(Account account, int tableNum) {
		loggedOn.get(account).println("tableRemoved" + "\n" + tableNum);
	}

	private static java.util.Collection<Account> deserializeAccounts() throws IOException {
		java.util.List<Account> accounts = new java.util.LinkedList<Account>();
		try {
			nu.xom.Element accountElement, root = new nu.xom.Builder().build(new java.io.File("accounts.xml")).getRootElement();
			if ("accounts".equals(root.getQualifiedName()))
				for (int i = 0; i < root.getChildElements().size(); ++i)
					if ("account".equals((accountElement = root.getChildElements().get(i)).getQualifiedName()))
						accounts.add(new Account(accountElement.getAttributeValue("username"), accountElement.getAttributeValue("password")));
		} catch (nu.xom.ValidityException e) {
			e.printStackTrace();
			throw new IOException("Invalid XML in accounts.xml");
		} catch (nu.xom.ParsingException e) {
			e.printStackTrace();
			throw new IOException("Parsing error in accounts.xml");
		}
		return accounts;
	}

	private static void serializeAccounts(java.util.Collection<Account> accounts) throws java.io.FileNotFoundException {
		java.io.PrintWriter out = new java.io.PrintWriter(new java.io.FileOutputStream("accounts.xml"));
		out.println("<?xml version='1.0' encoding='UTF-8' ?>");
		out.println("<accounts>");
		for (Account account : accounts)
			out.println("<account username='" + account.getUsername() + "' password='" + account.getPassword() + "'/>");
		out.println("</accounts>");
		out.close();
	}

	private class ClientListener extends Thread {
		private ClientListener(Account account, Connection connection) {
			this.account = account;
			this.connection = connection;
		}
		public void run() {
			try {
				String line;
				while ((line = connection.readLine()) != null) {
					if ("lobbyMessage".equals(line))
						sendLobbyMessage(account, connection.readLine());
					else if ("privateMessage".equals(line))
						sendPrivateMessage(account, accounts.get(connection.readLine()), connection.readLine());
					else if ("guiClosed".equals(line)) 
						removeClient(accounts.get(connection.readLine()));
				}
			}
			catch (IOException e) {}
			finally {
				removeClient(account);
				try {
					connection.socket().close();
				} catch (IOException e) {}
			}
		}

		private final Account account;
		private final Connection connection;
	}

	private class TableListener extends Thread {
		private TableListener(int tableNum, Connection connection) {
			this.tableNum = tableNum;
			this.connection = connection;
		}
		public void run() {
			try {
				while (connection.readLine() != null)
					;
			}
			catch (IOException e) {}
			finally {
				removeTable(tableNum);
				try {
					connection.socket().close();
				} catch (IOException e) {}
			}
		}
		private final int tableNum;
		private final Connection connection;
	}

	/** A list of all accounts registered with this server, mapped by username */
	private static final Map<String, Account> accounts = new HashMap<String, Account>();
	/** A list of Accounts that are currently logged on to the MasterServer */
	private static final Map<Account, Connection> loggedOn = new HashMap<Account, Connection>();
	/** A list of tables available on the MasterServer */
	private static final Map<Integer, net.GameInfo> tables = new HashMap<Integer, net.GameInfo>();

	private static MasterServer instance = null;

	private final java.net.ServerSocket tableSocket, clientSocket;
}